--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 6008b2205c50a1bb529bd612d988e05e50b1eb7a
Parents : 89477ff
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-08T09:25:00-05:00
feat(notification-sounds): implement notification sound management and settings and faster conversations/nomadnet browser loading
Changes
40 files changed, 1730 insertions(+), 46 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 9ef57e4d..5c311963 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -594,6 +594,19 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.ringtone_manager = value
+ @property
+ def notification_sound_manager(self):
+ return (
+ self.current_context.notification_sound_manager
+ if self.current_context
+ else None
+ )
+
+ @notification_sound_manager.setter
+ def notification_sound_manager(self, value):
+ if self.current_context:
+ self.current_context.notification_sound_manager = value
+
@property
def rncp_handler(self):
return self.current_context.rncp_handler if self.current_context else None
@@ -9471,6 +9484,181 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
+ # notification sound routes
+ @routes.get("/api/v1/notification-sounds")
+ async def notification_sounds_get(request):
+ sounds = self.database.notification_sounds.get_all()
+ return web.json_response(
+ [
+ {
+ "id": s["id"],
+ "filename": s["filename"],
+ "display_name": s["display_name"],
+ "is_primary": bool(s["is_primary"]),
+ "created_at": s["created_at"],
+ }
+ for s in sounds
+ ],
+ )
+
+ @routes.get("/api/v1/notification-sounds/status")
+ async def notification_sound_status(request):
+ try:
+ sound_id = None
+
+ preferred_id = self.config.notification_sound_preferred_id.get()
+ if preferred_id and preferred_id > 0:
+ sound_id = preferred_id
+
+ if sound_id is None:
+ primary = self.database.notification_sounds.get_primary()
+ if primary:
+ sound_id = primary["id"]
+
+ has_sound = sound_id is not None
+ sound = (
+ self.database.notification_sounds.get_by_id(sound_id)
+ if sound_id
+ else None
+ )
+
+ return web.json_response(
+ {
+ "has_sound": has_sound and sound is not None,
+ "enabled": self.config.notification_sound_enabled.get(),
+ "filename": sound["filename"] if sound else None,
+ "id": sound_id,
+ "volume": self.config.notification_sound_volume.get() / 100.0,
+ },
+ )
+ except Exception as e:
+ logger.error(f"Error in notification_sound_status: {e}")
+ return web.json_response(
+ {
+ "has_sound": False,
+ "enabled": self.config.notification_sound_enabled.get(),
+ "filename": None,
+ "id": None,
+ "volume": self.config.notification_sound_volume.get() / 100.0,
+ },
+ )
+
+ @routes.get("/api/v1/notification-sounds/{id}/audio")
+ async def notification_sound_audio(request):
+ sound_id = int(request.match_info["id"])
+ sound = self.database.notification_sounds.get_by_id(sound_id)
+ if not sound:
+ return web.Response(status=404)
+
+ if not self.notification_sound_manager:
+ return web.Response(status=503)
+
+ filepath = self.notification_sound_manager.get_ringtone_path(
+ sound["storage_filename"],
+ )
+ if not os.path.exists(filepath):
+ return web.Response(status=404)
+
+ return web.FileResponse(
+ filepath,
+ headers={
+ "Content-Type": "audio/ogg",
+ "Content-Disposition": f'attachment; filename="{sound["filename"]}"',
+ },
+ )
+
+ @routes.post("/api/v1/notification-sounds/upload")
+ async def notification_sound_upload(request):
+ if not self.notification_sound_manager:
+ return web.json_response(
+ {"message": "Notification sound manager unavailable"},
+ status=503,
+ )
+ try:
+ reader = await request.multipart()
+ field = await reader.next()
+ if field.name != "file":
+ return web.json_response(
+ {"message": "File field required"},
+ status=400,
+ )
+
+ filename = field.filename
+ extension = os.path.splitext(filename)[1].lower()
+ if extension not in [".mp3", ".ogg", ".wav", ".m4a", ".flac"]:
+ return web.json_response(
+ {"message": f"Unsupported file type: {extension}"},
+ status=400,
+ )
+
+ with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as f:
+ temp_path = f.name
+ while True:
+ chunk = await field.read_chunk()
+ if not chunk:
+ break
+ f.write(chunk)
+
+ try:
+ storage_filename = await asyncio.to_thread(
+ self.notification_sound_manager.convert_to_ringtone,
+ temp_path,
+ )
+
+ sound_id = self.database.notification_sounds.add(
+ filename=filename,
+ storage_filename=storage_filename,
+ )
+
+ return web.json_response(
+ {
+ "message": "Notification sound uploaded and converted",
+ "id": sound_id,
+ "filename": filename,
+ "storage_filename": storage_filename,
+ },
+ )
+ finally:
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.patch("/api/v1/notification-sounds/{id}")
+ async def notification_sound_patch(request):
+ try:
+ sound_id = int(request.match_info["id"])
+ data = await request.json()
+
+ display_name = data.get("display_name")
+ is_primary = 1 if data.get("is_primary") else None
+
+ self.database.notification_sounds.update(
+ sound_id,
+ display_name=display_name,
+ is_primary=is_primary,
+ )
+
+ return web.json_response({"message": "Notification sound updated"})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.delete("/api/v1/notification-sounds/{id}")
+ async def notification_sound_delete(request):
+ try:
+ sound_id = int(request.match_info["id"])
+ sound = self.database.notification_sounds.get_by_id(sound_id)
+ if sound:
+ if self.notification_sound_manager:
+ self.notification_sound_manager.remove_ringtone(
+ sound["storage_filename"],
+ )
+ self.database.notification_sounds.delete(sound_id)
+ return web.json_response({"message": "Notification sound deleted"})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
# contacts routes
@routes.get("/api/v1/telephone/contacts")
async def telephone_contacts_get(request):
@@ -15595,6 +15783,19 @@ class ReticulumMeshChat:
if value is not None:
self.config.ringtone_volume.set(value)
+ if "notification_sound_enabled" in data:
+ self.config.notification_sound_enabled.set(
+ self._parse_bool(data["notification_sound_enabled"]),
+ )
+ if "notification_sound_preferred_id" in data:
+ value = self._coerce_int(data["notification_sound_preferred_id"])
+ if value is not None:
+ self.config.notification_sound_preferred_id.set(value)
+ if "notification_sound_volume" in data:
+ value = self._coerce_int(data["notification_sound_volume"])
+ if value is not None:
+ self.config.notification_sound_volume.set(value)
+
if "do_not_disturb_enabled" in data:
self.config.do_not_disturb_enabled.set(
self._parse_bool(data["do_not_disturb_enabled"]),
@@ -16864,6 +17065,9 @@ class ReticulumMeshChat:
"ringtone_filename": ctx.config.ringtone_filename.get(),
"ringtone_preferred_id": ctx.config.ringtone_preferred_id.get(),
"ringtone_volume": ctx.config.ringtone_volume.get(),
+ "notification_sound_enabled": ctx.config.notification_sound_enabled.get(),
+ "notification_sound_preferred_id": ctx.config.notification_sound_preferred_id.get(),
+ "notification_sound_volume": ctx.config.notification_sound_volume.get(),
"map_offline_enabled": ctx.config.map_offline_enabled.get(),
"map_mbtiles_dir": ctx.config.map_mbtiles_dir.get(),
"map_tile_cache_enabled": ctx.config.map_tile_cache_enabled.get(),
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 33303661..a09068b8 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -213,6 +213,23 @@ class ConfigManager:
self.ringtone_preferred_id = self.IntConfig(self, "ringtone_preferred_id", 0)
self.ringtone_volume = self.IntConfig(self, "ringtone_volume", 100)
+ # notification sound config
+ self.notification_sound_enabled = self.BoolConfig(
+ self,
+ "notification_sound_enabled",
+ False,
+ )
+ self.notification_sound_preferred_id = self.IntConfig(
+ self,
+ "notification_sound_preferred_id",
+ 0,
+ )
+ self.notification_sound_volume = self.IntConfig(
+ self,
+ "notification_sound_volume",
+ 100,
+ )
+
# telephony config
self.telephone_enabled = self.BoolConfig(
self,
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 9e28d1a6..beaf5714 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -19,6 +19,7 @@ from .map_drawings import MapDrawingsDAO
from .messages import MessageDAO
from .misc import MiscDAO
from .provider import DatabaseProvider
+from .notification_sounds import NotificationSoundDAO
from .ringtones import RingtoneDAO
from .schema import DatabaseSchema
from .sticker_packs import UserStickerPacksDAO
@@ -77,6 +78,7 @@ class Database:
self.telemetry = TelemetryDAO(self.provider)
self.voicemails = VoicemailDAO(self.provider)
self.ringtones = RingtoneDAO(self.provider)
+ self.notification_sounds = NotificationSoundDAO(self.provider)
self.contacts = ContactsDAO(self.provider)
self.map_drawings = MapDrawingsDAO(self.provider)
self.stickers = UserStickersDAO(self.provider)
diff --git a/meshchatx/src/backend/database/notification_sounds.py b/meshchatx/src/backend/database/notification_sounds.py
new file mode 100644
index 00000000..ea6a5bca
--- /dev/null
+++ b/meshchatx/src/backend/database/notification_sounds.py
@@ -0,0 +1,84 @@
+# SPDX-License-Identifier: 0BSD
+
+from datetime import UTC, datetime
+
+from .provider import DatabaseProvider
+
+
+class NotificationSoundDAO:
+ def __init__(self, provider: DatabaseProvider):
+ self.provider = provider
+
+ def get_all(self):
+ return self.provider.fetchall(
+ "SELECT * FROM notification_sounds ORDER BY created_at DESC",
+ )
+
+ def get_by_id(self, sound_id):
+ return self.provider.fetchone(
+ "SELECT * FROM notification_sounds WHERE id = ?",
+ (sound_id,),
+ )
+
+ def get_primary(self):
+ return self.provider.fetchone(
+ "SELECT * FROM notification_sounds WHERE is_primary = 1",
+ )
+
+ def add(self, filename, storage_filename, display_name=None):
+ now = datetime.now(UTC)
+ if display_name is None:
+ display_name = filename
+
+ count = self.provider.fetchone(
+ "SELECT COUNT(*) as count FROM notification_sounds",
+ )["count"]
+ is_primary = 1 if count == 0 else 0
+
+ cursor = self.provider.execute(
+ "INSERT INTO notification_sounds (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
+ (filename, display_name, storage_filename, is_primary, now, now),
+ )
+ return cursor.lastrowid
+
+ def update(self, sound_id, display_name=None, is_primary=None):
+ now = datetime.now(UTC)
+ if is_primary == 1:
+ self.provider.execute(
+ "UPDATE notification_sounds SET is_primary = 0, updated_at = ?",
+ (now,),
+ )
+
+ if display_name is not None and is_primary is not None:
+ self.provider.execute(
+ "UPDATE notification_sounds SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?",
+ (display_name, is_primary, now, sound_id),
+ )
+ elif display_name is not None:
+ self.provider.execute(
+ "UPDATE notification_sounds SET display_name = ?, updated_at = ? WHERE id = ?",
+ (display_name, now, sound_id),
+ )
+ elif is_primary is not None:
+ self.provider.execute(
+ "UPDATE notification_sounds SET is_primary = ?, updated_at = ? WHERE id = ?",
+ (is_primary, now, sound_id),
+ )
+
+ def delete(self, sound_id):
+ sound = self.get_by_id(sound_id)
+ if sound and sound["is_primary"] == 1:
+ self.provider.execute(
+ "DELETE FROM notification_sounds WHERE id = ?",
+ (sound_id,),
+ )
+ next_sound = self.provider.fetchone(
+ "SELECT id FROM notification_sounds LIMIT 1",
+ )
+ if next_sound:
+ self.update(next_sound["id"], is_primary=1)
+ else:
+ self.provider.execute(
+ "DELETE FROM notification_sounds WHERE id = ?",
+ (sound_id,),
+ )
diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 2d4033fe..0ea4c91f 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -19,7 +19,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str:
class DatabaseSchema:
- LATEST_VERSION = 48
+ LATEST_VERSION = 49
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -386,6 +386,17 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
+ "notification_sounds": """
+ CREATE TABLE IF NOT EXISTS notification_sounds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ filename TEXT,
+ display_name TEXT,
+ storage_filename TEXT,
+ is_primary INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
"contacts": """
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1294,3 +1305,16 @@ class DatabaseSchema:
if current_version < 48:
self._ensure_column("lxmf_messages", "path_finding_measure", "TEXT")
self._ensure_column("lxmf_messages", "path_row_hash_hex", "TEXT")
+
+ if current_version < 49:
+ self._safe_execute("""
+ CREATE TABLE IF NOT EXISTS notification_sounds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ filename TEXT,
+ display_name TEXT,
+ storage_filename TEXT,
+ is_primary INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 48833a44..7ba13b66 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -87,6 +87,7 @@ class IdentityContext:
self.telephone_manager = None
self.voicemail_manager = None
self.ringtone_manager = None
+ self.notification_sound_manager = None
self.auto_propagation_manager = None
self.rncp_handler = None
self.rnsh_manager = None
@@ -391,6 +392,13 @@ class IdentityContext:
storage_dir=self.storage_path,
)
+ self.notification_sound_manager = RingtoneManager(
+ config=self.config,
+ storage_dir=self.storage_path,
+ asset_subdir="notification_sounds",
+ filename_prefix="notification",
+ )
+
self.community_interfaces_manager = CommunityInterfacesManager(
public_override_path=self.app.get_public_path("community_interfaces.json"),
cache_path=os.path.join(
diff --git a/meshchatx/src/backend/ringtone_manager.py b/meshchatx/src/backend/ringtone_manager.py
index d7e08f39..92888c99 100644
--- a/meshchatx/src/backend/ringtone_manager.py
+++ b/meshchatx/src/backend/ringtone_manager.py
@@ -13,9 +13,18 @@ def _ringtone_profile():
class RingtoneManager:
- def __init__(self, config, storage_dir):
+ def __init__(
+ self,
+ config,
+ storage_dir,
+ *,
+ asset_subdir="ringtones",
+ filename_prefix="ringtone",
+ ):
self.config = config
- self.storage_dir = os.path.join(storage_dir, "ringtones")
+ self.asset_subdir = asset_subdir
+ self.filename_prefix = filename_prefix
+ self.storage_dir = os.path.join(storage_dir, asset_subdir)
os.makedirs(self.storage_dir, exist_ok=True)
@@ -30,7 +39,7 @@ class RingtoneManager:
"""
import secrets
- filename = f"ringtone_{secrets.token_hex(8)}.opus"
+ filename = f"{self.filename_prefix}_{secrets.token_hex(8)}.opus"
opus_path = os.path.join(self.storage_dir, filename)
encode_audio_to_ogg_opus(input_path, opus_path, profile=_ringtone_profile())
return filename
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b3fa99ed..0e334beb 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -574,6 +574,7 @@ import { countRelayMentions } from "../js/relayMentionCount.js";
import Utils from "../js/Utils";
import GlobalEmitter from "../js/GlobalEmitter";
import NotificationUtils from "../js/NotificationUtils";
+import NotificationSoundUtils from "../js/NotificationSoundUtils";
import LxmfUserIcon from "./LxmfUserIcon.vue";
import Toast from "./Toast.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
@@ -869,6 +870,7 @@ export default {
return count;
},
onRingtoneUnlockGesture() {
+ NotificationSoundUtils.unlockAutoplay();
if (!this.ringtoneAutoplayBlocked) {
return;
}
@@ -1330,7 +1332,7 @@ export default {
"rrc.change": () => {
this.updateRelayChatUnreadCount();
},
- "lxmf.delivery": (json) => {
+ "lxmf.delivery": async (json) => {
if (this.config?.do_not_disturb_enabled) {
return;
}
@@ -1338,14 +1340,18 @@ export default {
return;
}
this.updateUnreadConversationsCount();
- if (
- !document.hasFocus() &&
+ const isIncomingMessage =
json.lxmf_message?.is_incoming === true &&
- (json.lxmf_message?.content || json.lxmf_message?.title)
- ) {
+ (json.lxmf_message?.content || json.lxmf_message?.title);
+ let playedNotificationSound = false;
+ if (isIncomingMessage) {
+ playedNotificationSound = await NotificationSoundUtils.play(this.config);
+ }
+ if (!document.hasFocus() && isIncomingMessage) {
NotificationUtils.showNewMessageNotification(
json.remote_identity_name,
- json.lxmf_message?.content
+ json.lxmf_message?.content,
+ playedNotificationSound
);
}
},
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 6ed7d7c8..8d304630 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -6926,13 +6926,13 @@ export default {
try {
await window.api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`);
GlobalEmitter.emit("notifications-changed");
+ if (GlobalState.unreadConversationsCount > 0) {
+ GlobalState.unreadConversationsCount -= 1;
+ }
} catch (e) {
// do nothing if failed to mark as read
console.log(e);
}
-
- // reload conversations
- this.$emit("reload-conversations");
},
toggleSentMessageInfo: function (messageHash) {
if (this.expandedMessageInfo === messageHash) {
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 2924159e..01da36fc 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -41,6 +41,7 @@
@ingest-paper-message="openIngestPaperMessageModal"
@load-more="loadMoreConversations"
@load-more-announces="loadMoreAnnounces"
+ @announces-tab-activated="onAnnouncesTabActivated"
@folder-click="onFolderClick"
@create-folder="onCreateFolder"
@rename-folder="onRenameFolder"
@@ -93,7 +94,7 @@
@update:selected-peer="onPanePeerUpdate(pane.id, $event)"
@update-peer-tracking="onUpdatePeerTracking"
@close="onPaneClose(pane.id)"
- @reload-conversations="getConversations"
+ @reload-conversations="requestConversationsRefresh"
/>
<div
v-if="!pane.peer"
@@ -331,6 +332,11 @@ import {
startCameraStream,
} from "../../js/qrScannerUtils";
import { lxmfConversationListPreview } from "../../js/lxmfConversationPreview";
+import {
+ conversationListSignature,
+ countUnreadConversations,
+ syncConversationListInPlace,
+} from "../../js/lxmfConversationListSync";
import {
loadMessagePanes,
saveMessagePanes,
@@ -381,6 +387,8 @@ export default {
threePaneViewportListener: null,
conversations: [],
+ conversationListSignature: "",
+ announcesLoaded: false,
folders: [],
selectedFolderId: null,
pageSize: 50,
@@ -492,10 +500,7 @@ export default {
saveFeatureSidebarCollapsed("messages", collapsed);
},
conversations() {
- // update global state
- GlobalState.unreadConversationsCount = this.conversations.filter((conversation) => {
- return conversation.is_unread;
- }).length;
+ this.syncUnreadCount();
},
paneLayoutSignature() {
this.persistPanes();
@@ -552,7 +557,6 @@ export default {
this.getConversations();
this.loadConversationPins();
this.getFolders();
- this.getLxmfDeliveryAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
@@ -566,6 +570,16 @@ export default {
}
},
methods: {
+ syncUnreadCount() {
+ GlobalState.unreadConversationsCount = countUnreadConversations(this.conversations);
+ },
+ onAnnouncesTabActivated() {
+ if (this.announcesLoaded) {
+ return;
+ }
+ this.announcesLoaded = true;
+ this.getLxmfDeliveryAnnounces();
+ },
async onComposeNewMessage(destinationHash) {
if (destinationHash == null) {
if (this.selectedPeer) {
@@ -634,7 +648,7 @@ export default {
break;
}
case "lxmf.delivery": {
- await this.getConversations();
+ this.requestConversationsRefresh();
break;
}
case "lxmf_message_created": {
@@ -684,6 +698,9 @@ export default {
}
},
async getLxmfDeliveryAnnounces(append = false) {
+ if (!append) {
+ this.announcesLoaded = true;
+ }
try {
if (!append) {
if (this.announcesAbortController) {
@@ -775,10 +792,31 @@ export default {
});
const newConversations = response.data.conversations;
+ if (!append) {
+ const nextSignature = conversationListSignature(newConversations);
+ if (nextSignature === this.conversationListSignature) {
+ this.hasLoadedConversations = true;
+ this.hasMoreConversations = newConversations.length === this.pageSize;
+ return;
+ }
+ }
+
if (append) {
this.conversations = [...this.conversations, ...newConversations];
+ this.conversationListSignature = conversationListSignature(this.conversations);
} else {
- this.conversations = newConversations;
+ const nextSignature = conversationListSignature(newConversations);
+ if (nextSignature !== this.conversationListSignature) {
+ this.conversationListSignature = nextSignature;
+ if (this.conversations.length === 0) {
+ this.conversations = newConversations.slice();
+ } else {
+ const structureChanged = syncConversationListInPlace(this.conversations, newConversations);
+ if (!structureChanged) {
+ this.conversations = this.conversations.slice();
+ }
+ }
+ }
}
for (const conversation of newConversations) {
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 26867f29..550ed355 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -911,6 +911,7 @@ export default {
"ingest-paper-message",
"load-more",
"load-more-announces",
+ "announces-tab-activated",
"folder-click",
"create-folder",
"rename-folder",
@@ -1064,6 +1065,11 @@ export default {
},
},
watch: {
+ tab(newTab, oldTab) {
+ if (newTab === "announces" && oldTab !== "announces") {
+ this.$emit("announces-tab-activated");
+ }
+ },
foldersExpanded(newVal) {
try {
if (typeof localStorage !== "undefined") {
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
index 0bcc29b9..66a1227f 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
@@ -50,21 +50,22 @@
</div>
<div class="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
- <NomadNetworkPage
- v-for="tab in tabs"
- v-show="tab.id === activeTabId"
- :key="tab.id"
- :ref="(el) => setPageRef(tab.id, el)"
- class="absolute inset-0 flex min-h-0 min-w-0"
- embedded
- :tabs-enabled="tabsEnabled"
- :is-active="tab.id === activeTabId"
- :destination-hash="tab.destinationHash"
- :initial-path="tab.initialPath"
- @navigate="onTabNavigate(tab.id, $event)"
- @open-node="onOpenNode"
- @close-tab="closeTab(tab.id)"
- />
+ <template v-for="tab in tabs" :key="tab.id">
+ <NomadNetworkPage
+ v-if="isTabMounted(tab.id)"
+ v-show="tab.id === activeTabId"
+ :ref="(el) => setPageRef(tab.id, el)"
+ class="absolute inset-0 flex min-h-0 min-w-0"
+ embedded
+ :tabs-enabled="tabsEnabled"
+ :is-active="tab.id === activeTabId"
+ :destination-hash="tab.destinationHash"
+ :initial-path="tab.initialPath"
+ @navigate="onTabNavigate(tab.id, $event)"
+ @open-node="onOpenNode"
+ @close-tab="closeTab(tab.id)"
+ />
+ </template>
</div>
<NomadBrowserContextMenu
@@ -136,6 +137,7 @@ export default {
mediaQueryListener: null,
dragTabIndex: null,
pageRefs: {},
+ mountedTabIds: {},
contextMenu: {
show: false,
justOpened: false,
@@ -216,6 +218,7 @@ export default {
}
GlobalEmitter.on("nomad-open-node", this.handleNomadOpenNode);
+ this.mountTab(this.activeTabId);
},
activated() {
if (this.$route?.query?.newTab === "1") {
@@ -256,6 +259,24 @@ export default {
this.mediaQuery = null;
this.mediaQueryListener = null;
},
+ mountTab(tabId) {
+ if (tabId == null || this.mountedTabIds[tabId]) {
+ return;
+ }
+ this.mountedTabIds = { ...this.mountedTabIds, [tabId]: true };
+ },
+ isTabMounted(tabId) {
+ return Boolean(this.mountedTabIds[tabId]);
+ },
+ unmountTab(tabId) {
+ if (tabId == null || !this.mountedTabIds[tabId]) {
+ return;
+ }
+ delete this.pageRefs[tabId];
+ const nextMounted = { ...this.mountedTabIds };
+ delete nextMounted[tabId];
+ this.mountedTabIds = nextMounted;
+ },
addTab(destinationHash = "", initialPath = null, title = null, activate = true) {
const id = this.nextTabId++;
this.tabs.push({
@@ -267,6 +288,7 @@ export default {
});
if (activate) {
this.activeTabId = id;
+ this.mountTab(id);
this.syncRoute();
}
return id;
@@ -444,6 +466,7 @@ export default {
return;
}
this.activeTabId = tabId;
+ this.mountTab(tabId);
this.syncRoute();
this.$nextTick(() => {
this.verifyActiveTabPage(tab);
@@ -494,6 +517,7 @@ export default {
const wasActive = this.tabs[index].id === this.activeTabId;
this.tabs.splice(index, 1);
+ this.unmountTab(tabId);
if (this.tabs.length === 0) {
this.addTab();
@@ -503,6 +527,7 @@ export default {
if (wasActive) {
const neighbour = this.tabs[index] || this.tabs[index - 1] || this.tabs[0];
this.activeTabId = neighbour.id;
+ this.mountTab(neighbour.id);
}
this.syncRoute();
},
diff --git a/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue b/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue
new file mode 100644
index 00000000..7335f6d0
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue
@@ -0,0 +1,266 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <section v-show="showSection" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">{{ $t("app.notifications") }}</div>
+ <h2>{{ $t("app.notification_sound_settings") }}</h2>
+ <p>{{ $t("app.notification_sound_settings_description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <label class="setting-toggle">
+ <Toggle
+ id="notification-sound-enabled"
+ :model-value="config.notification_sound_enabled"
+ @update:model-value="onEnabledChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.enable_notification_sound") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.enable_notification_sound_description")
+ }}</span>
+ </span>
+ </label>
+
+ <div v-if="config.notification_sound_enabled" class="space-y-4">
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
+ {{ $t("app.notification_sound_volume") }}
+ </label>
+ <span class="text-xs font-mono text-gray-400">{{ config.notification_sound_volume }}%</span>
+ </div>
+ <input
+ :value="config.notification_sound_volume"
+ type="range"
+ min="0"
+ max="100"
+ class="w-full h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
+ @input="onVolumeChange"
+ />
+ </div>
+
+ <div class="space-y-2">
+ <label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
+ {{ $t("app.notification_sound_default") }}
+ </label>
+ <select
+ :value="config.notification_sound_preferred_id"
+ class="input-field py-1.5! px-3! text-sm! rounded-xl! border-gray-200! dark:border-zinc-800! w-full max-w-md"
+ @change="onPreferredChange"
+ >
+ <option :value="0">{{ $t("app.notification_sound_primary_default") }}</option>
+ <option v-for="sound in sounds" :key="sound.id" :value="sound.id">
+ {{ sound.display_name }}
+ </option>
+ </select>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
+ {{ $t("app.notification_sounds") }}
+ </label>
+ <button
+ type="button"
+ class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
+ :disabled="isUploading"
+ @click="$refs.soundUpload.click()"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ {{ isUploading ? $t("app.notification_sound_uploading") : $t("app.notification_sound_upload") }}
+ </button>
+ <input
+ ref="soundUpload"
+ type="file"
+ accept=".mp3,.ogg,.wav,.m4a,.flac,audio/*"
+ class="hidden"
+ @change="uploadSound"
+ />
+ </div>
+
+ <p v-if="sounds.length === 0" class="text-sm text-gray-500 dark:text-zinc-400">
+ {{ $t("app.notification_sound_none_uploaded") }}
+ </p>
+
+ <div v-else class="grid gap-3">
+ <div
+ v-for="sound in sounds"
+ :key="sound.id"
+ class="flex items-center justify-between gap-3 rounded-xl border border-gray-200 dark:border-zinc-800 px-3 py-2"
+ >
+ <div class="min-w-0 flex-1">
+ <div class="text-sm font-semibold truncate">{{ sound.display_name }}</div>
+ <div v-if="sound.is_primary" class="text-xs text-blue-600 dark:text-blue-400">
+ {{ $t("app.notification_sound_primary") }}
+ </div>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800"
+ :title="$t('app.notification_sound_preview')"
+ @click="previewSound(sound)"
+ >
+ <MaterialDesignIcon
+ :icon-name="playingSoundId === sound.id ? 'stop' : 'play'"
+ class="size-4"
+ />
+ </button>
+ <button
+ v-if="!sound.is_primary"
+ type="button"
+ class="rounded-lg px-2 py-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20"
+ @click="setPrimarySound(sound)"
+ >
+ {{ $t("app.notification_sound_set_primary") }}
+ </button>
+ <button
+ type="button"
+ class="rounded-lg p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
+ :title="$t('app.notification_sound_remove')"
+ @click="deleteSound(sound)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import Toggle from "../forms/Toggle.vue";
+import ToastUtils from "../../js/ToastUtils";
+import NotificationSoundUtils from "../../js/NotificationSoundUtils";
+
+export default {
+ name: "NotificationSoundSettings",
+ components: {
+ MaterialDesignIcon,
+ Toggle,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ showSection: {
+ type: Boolean,
+ default: true,
+ },
+ updateConfig: {
+ type: Function,
+ required: true,
+ },
+ },
+ emits: ["sounds-changed"],
+ data() {
+ return {
+ sounds: [],
+ isUploading: false,
+ playingSoundId: null,
+ };
+ },
+ mounted() {
+ this.loadSounds();
+ },
+ methods: {
+ async loadSounds() {
+ try {
+ const response = await window.api.get("/api/v1/notification-sounds");
+ this.sounds = response.data ?? [];
+ } catch (error) {
+ console.error("Failed to load notification sounds:", error);
+ this.sounds = [];
+ }
+ },
+ onEnabledChange(value) {
+ this.updateConfig({ notification_sound_enabled: value }, "notification_sound_enabled");
+ },
+ onVolumeChange(event) {
+ const value = Number(event.target.value);
+ this.updateConfig({ notification_sound_volume: value }, "notification_sound_volume");
+ },
+ onPreferredChange(event) {
+ const value = Number(event.target.value);
+ this.updateConfig({ notification_sound_preferred_id: value }, "notification_sound_preferred_id");
+ },
+ async uploadSound(event) {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ this.isUploading = true;
+ const formData = new FormData();
+ formData.append("file", file);
+
+ try {
+ await window.api.post("/api/v1/notification-sounds/upload", formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ });
+ ToastUtils.success(this.$t("app.notification_sound_uploaded"));
+ await this.loadSounds();
+ this.$emit("sounds-changed");
+ } catch (error) {
+ console.error(error);
+ ToastUtils.error(error.response?.data?.message || this.$t("app.notification_sound_upload_failed"));
+ } finally {
+ this.isUploading = false;
+ event.target.value = "";
+ }
+ },
+ async deleteSound(sound) {
+ if (!confirm(this.$t("common.delete_confirm"))) {
+ return;
+ }
+ try {
+ await window.api.delete(`/api/v1/notification-sounds/${sound.id}`);
+ ToastUtils.success(this.$t("app.notification_sound_deleted"));
+ if (this.playingSoundId === sound.id) {
+ NotificationSoundUtils.stop();
+ this.playingSoundId = null;
+ }
+ await this.loadSounds();
+ this.$emit("sounds-changed");
+ } catch (error) {
+ console.error(error);
+ ToastUtils.error(this.$t("app.notification_sound_delete_failed"));
+ }
+ },
+ async setPrimarySound(sound) {
+ try {
+ await window.api.patch(`/api/v1/notification-sounds/${sound.id}`, {
+ is_primary: true,
+ });
+ ToastUtils.success(this.$t("app.notification_sound_primary_set"));
+ await this.loadSounds();
+ } catch (error) {
+ console.error(error);
+ ToastUtils.error(this.$t("app.notification_sound_primary_set_failed"));
+ }
+ },
+ async previewSound(sound) {
+ if (this.playingSoundId === sound.id) {
+ NotificationSoundUtils.stop();
+ this.playingSoundId = null;
+ return;
+ }
+
+ const played = await NotificationSoundUtils.preview(sound.id, this.config.notification_sound_volume ?? 100);
+ if (played) {
+ this.playingSoundId = sound.id;
+ return;
+ }
+ ToastUtils.warning(this.$t("app.notification_sound_preview_failed"));
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index add3a1cd..218fd655 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2311,6 +2311,13 @@
</div>
</section>
+ <NotificationSoundSettings
+ v-show="showSection('notificationSounds')"
+ :config="config"
+ :show-section="showSection('notificationSounds')"
+ :update-config="updateConfig"
+ />
+
<!-- Messages (LXMF delivery, retries, inbound stamps) -->
<section v-show="showSection('messages')" class="settings-section break-inside-avoid">
<header class="settings-section__header">
@@ -2798,6 +2805,7 @@ import { DEFAULT_SETTINGS_TAB, normalizeSettingsTabId, SETTINGS_TABS } from "../
import { getAllSettingsSectionKeywords } from "../../js/registries/settingsSectionRegistry.js";
import { isMicronWasmBundled } from "../../js/MicronWasmLoader.js";
import MicronWasmUpdateModal from "./MicronWasmUpdateModal.vue";
+import NotificationSoundSettings from "./NotificationSoundSettings.vue";
import PluginsSettingsSection from "./PluginsSettingsSection.vue";
export default {
@@ -2811,6 +2819,7 @@ export default {
StickerPacksManager,
PluginsSettingsSection,
MicronWasmUpdateModal,
+ NotificationSoundSettings,
},
data() {
return {
diff --git a/meshchatx/src/frontend/js/NotificationSoundUtils.js b/meshchatx/src/frontend/js/NotificationSoundUtils.js
new file mode 100644
index 00000000..473aca65
--- /dev/null
+++ b/meshchatx/src/frontend/js/NotificationSoundUtils.js
@@ -0,0 +1,123 @@
+class NotificationSoundUtils {
+ static _player = null;
+ static autoplayBlocked = false;
+
+ static isSupported() {
+ return typeof window !== "undefined" && typeof Audio !== "undefined";
+ }
+
+ static shouldPlay(config) {
+ return Boolean(config?.notification_sound_enabled);
+ }
+
+ static _normalizeVolume(volume) {
+ if (typeof volume !== "number" || Number.isNaN(volume)) {
+ return 1;
+ }
+ return Math.min(1, Math.max(0, volume));
+ }
+
+ static stop() {
+ if (!NotificationSoundUtils._player) {
+ return;
+ }
+ try {
+ NotificationSoundUtils._player.pause();
+ NotificationSoundUtils._player.currentTime = 0;
+ } catch {
+ // ignore pause errors
+ }
+ NotificationSoundUtils._player = null;
+ }
+
+ static unlockAutoplay() {
+ if (!NotificationSoundUtils.autoplayBlocked) {
+ return;
+ }
+ NotificationSoundUtils.autoplayBlocked = false;
+ }
+
+ static async _fetchStatus() {
+ if (typeof window === "undefined" || !window.api) {
+ return null;
+ }
+ const response = await window.api.get("/api/v1/notification-sounds/status");
+ return response?.data ?? null;
+ }
+
+ static async play(config) {
+ if (!NotificationSoundUtils.isSupported()) {
+ return false;
+ }
+ if (!NotificationSoundUtils.shouldPlay(config)) {
+ return false;
+ }
+ if (NotificationSoundUtils.autoplayBlocked) {
+ return false;
+ }
+
+ try {
+ const status = await NotificationSoundUtils._fetchStatus();
+ if (!status?.enabled || !status?.has_sound || !status?.id) {
+ return false;
+ }
+
+ NotificationSoundUtils.stop();
+
+ const player = new Audio(`/api/v1/notification-sounds/${status.id}/audio`);
+ player.loop = false;
+ player.volume = NotificationSoundUtils._normalizeVolume(
+ status.volume ?? config.notification_sound_volume / 100.0
+ );
+
+ player.onended = () => {
+ if (NotificationSoundUtils._player === player) {
+ NotificationSoundUtils._player = null;
+ }
+ };
+
+ NotificationSoundUtils._player = player;
+ await player.play();
+ return true;
+ } catch (error) {
+ if (error?.name === "NotAllowedError") {
+ NotificationSoundUtils.autoplayBlocked = true;
+ return false;
+ }
+ console.warn("Failed to play notification sound:", error);
+ return false;
+ }
+ }
+
+ static async preview(soundId, volumePercent = 100) {
+ if (!NotificationSoundUtils.isSupported() || !soundId) {
+ return false;
+ }
+
+ NotificationSoundUtils.stop();
+
+ try {
+ const player = new Audio(`/api/v1/notification-sounds/${soundId}/audio`);
+ player.loop = false;
+ player.volume = NotificationSoundUtils._normalizeVolume(volumePercent / 100.0);
+ player.onended = () => {
+ if (NotificationSoundUtils._player === player) {
+ NotificationSoundUtils._player = null;
+ }
+ };
+ NotificationSoundUtils._player = player;
+ await player.play();
+ NotificationSoundUtils.unlockAutoplay();
+ return true;
+ } catch (error) {
+ if (error?.name === "NotAllowedError") {
+ NotificationSoundUtils.autoplayBlocked = true;
+ return false;
+ }
+ console.warn("Failed to preview notification sound:", error);
+ return false;
+ }
+ }
+}
+
+export default NotificationSoundUtils;
diff --git a/meshchatx/src/frontend/js/NotificationUtils.js b/meshchatx/src/frontend/js/NotificationUtils.js
index c92d3705..8f50e883 100644
--- a/meshchatx/src/frontend/js/NotificationUtils.js
+++ b/meshchatx/src/frontend/js/NotificationUtils.js
@@ -68,11 +68,12 @@ class NotificationUtils {
});
}
- static showNewMessageNotification(from, content) {
+ static showNewMessageNotification(from, content, silent = false) {
if (window.electron) {
window.electron.showNotification(
"New Message",
- from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message."
+ from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message.",
+ silent
);
return;
}
diff --git a/meshchatx/src/frontend/js/lxmfConversationListSync.js b/meshchatx/src/frontend/js/lxmfConversationListSync.js
new file mode 100644
index 00000000..410be075
--- /dev/null
+++ b/meshchatx/src/frontend/js/lxmfConversationListSync.js
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Build a compact signature for a conversation list page.
+ * Used to skip redundant sidebar refreshes when polling returns unchanged data.
+ *
+ * @param {Array<{ destination_hash?: string, updated_at?: string, is_unread?: boolean, failed_messages_count?: number, latest_message_created_at?: number | string | null }>} conversations
+ * @returns {string}
+ */
+export function conversationListSignature(conversations) {
+ if (!Array.isArray(conversations) || conversations.length === 0) {
+ return "";
+ }
+ return conversations
+ .map((conversation) => {
+ const hash = conversation?.destination_hash || "";
+ const updatedAt = conversation?.updated_at || "";
+ const unread = conversation?.is_unread ? "1" : "0";
+ const failed = conversation?.failed_messages_count ?? 0;
+ const latest = conversation?.latest_message_created_at ?? "";
+ const preview = conversation?.latest_message_preview || "";
+ return `${hash}\u241f${updatedAt}\u241f${unread}\u241f${failed}\u241f${latest}\u241f${preview}`;
+ })
+ .join("\u241e");
+}
+
+/**
+ * Count unread conversations in a list.
+ *
+ * @param {Array<{ is_unread?: boolean }>} conversations
+ * @returns {number}
+ */
+export function countUnreadConversations(conversations) {
+ if (!Array.isArray(conversations)) {
+ return 0;
+ }
+ let count = 0;
+ for (const conversation of conversations) {
+ if (conversation?.is_unread) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+/**
+ * Apply a refreshed conversation page in place, preserving row object identity when possible.
+ *
+ * @param {Array<object>} existing
+ * @param {Array<object>} incoming
+ * @returns {boolean} true when any visible list state changed
+ */
+export function syncConversationListInPlace(existing, incoming) {
+ if (!Array.isArray(existing) || !Array.isArray(incoming)) {
+ return false;
+ }
+ if (incoming.length === 0) {
+ if (existing.length === 0) {
+ return false;
+ }
+ existing.length = 0;
+ return true;
+ }
+ if (existing.length === 0) {
+ existing.push(...incoming);
+ return true;
+ }
+
+ const existingByHash = new Map(existing.map((conversation) => [conversation.destination_hash, conversation]));
+ const nextRows = [];
+ for (const conversation of incoming) {
+ const hash = conversation?.destination_hash;
+ if (!hash) {
+ continue;
+ }
+ const previous = existingByHash.get(hash);
+ if (previous) {
+ Object.assign(previous, conversation);
+ nextRows.push(previous);
+ } else {
+ nextRows.push(conversation);
+ }
+ }
+
+ const sameOrderAndRefs =
+ nextRows.length === existing.length && nextRows.every((row, index) => row === existing[index]);
+ if (sameOrderAndRefs) {
+ return false;
+ }
+
+ existing.length = 0;
+ existing.push(...nextRows);
+ return true;
+}
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index aa110619..5892f30b 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -252,6 +252,17 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.flood_max_stamp_cost",
"app.flood_cooldown",
],
+ notificationSounds: [
+ "app.notifications",
+ "app.notification_sound_settings",
+ "app.notification_sound_settings_description",
+ "app.enable_notification_sound",
+ "app.enable_notification_sound_description",
+ "app.notification_sound_volume",
+ "app.notification_sound_default",
+ "app.notification_sounds",
+ "app.notification_sound_upload",
+ ],
propagation: [
"LXMF",
"app.incoming_message_size",
diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js
index 889767ef..6ac89c78 100644
--- a/meshchatx/src/frontend/js/settings/settingsTabs.js
+++ b/meshchatx/src/frontend/js/settings/settingsTabs.js
@@ -14,7 +14,16 @@ export const SETTINGS_TABS = [
id: "messages",
labelKey: "settings.tabs.messages",
descriptionKey: "settings.tabs.messages_desc",
- sections: ["strangerProtection", "messages", "propagation", "stickers", "gifs", "banishment", "telephony"],
+ sections: [
+ "strangerProtection",
+ "messages",
+ "notificationSounds",
+ "propagation",
+ "stickers",
+ "gifs",
+ "banishment",
+ "telephony",
+ ],
},
{
id: "network",
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index d696f849..2829a525 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -356,6 +356,29 @@
"notifications_no_new": "Keine neuen Benachrichtigungen",
"notifications_empty_history": "Kein Benachrichtigungsverlauf",
"notifications_history_title": "Letzter Benachrichtigungsverlauf",
+ "notifications": "Benachrichtigungen",
+ "notification_sound_settings": "Benachrichtigungston für Nachrichten",
+ "notification_sound_settings_description": "Spielt einen benutzerdefinierten Ton ab, wenn Sie eine neue Nachricht erhalten, während MeshChat geöffnet ist. Laden Sie zuerst eine Audiodatei hoch und aktivieren Sie dann die Wiedergabe.",
+ "enable_notification_sound": "Benachrichtigungston aktivieren",
+ "enable_notification_sound_description": "Spielt Ihren gewählten Ton für eingehende Nachrichten auf dem Desktop ab (Windows, macOS und Linux).",
+ "notification_sound_volume": "Benachrichtigungslautstärke",
+ "notification_sound_default": "Standard-Benachrichtigungston",
+ "notification_sound_primary_default": "Primär hochgeladener Ton",
+ "notification_sounds": "Hochgeladene Töne",
+ "notification_sound_upload": "Ton hochladen",
+ "notification_sound_uploading": "Wird hochgeladen...",
+ "notification_sound_none_uploaded": "Noch kein Benachrichtigungston hochgeladen. Laden Sie eine Audiodatei hoch, um zu beginnen.",
+ "notification_sound_primary": "Primär",
+ "notification_sound_preview": "Ton anhören",
+ "notification_sound_set_primary": "Als primär festlegen",
+ "notification_sound_remove": "Ton entfernen",
+ "notification_sound_uploaded": "Benachrichtigungston hochgeladen",
+ "notification_sound_upload_failed": "Benachrichtigungston konnte nicht hochgeladen werden",
+ "notification_sound_deleted": "Benachrichtigungston gelöscht",
+ "notification_sound_delete_failed": "Benachrichtigungston konnte nicht gelöscht werden",
+ "notification_sound_primary_set": "Primärer Benachrichtigungston aktualisiert",
+ "notification_sound_primary_set_failed": "Primärer Benachrichtigungston konnte nicht festgelegt werden",
+ "notification_sound_preview_failed": "Benachrichtigungston konnte nicht abgespielt werden. Versuchen Sie es erneut, nachdem Sie mit der App interagiert haben.",
"search_settings": "Einstellungen suchen...",
"show_qr": "QR-Code anzeigen",
"csp_settings": "Content-Security-Policy (CSP)",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 33f648ef..a08b26c5 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -92,6 +92,29 @@
"notifications_no_new": "No new notifications",
"notifications_empty_history": "No notification history",
"notifications_history_title": "Recent notification history",
+ "notifications": "Notifications",
+ "notification_sound_settings": "Message Notification Sound",
+ "notification_sound_settings_description": "Play a custom sound when you receive a new message while MeshChat is open. Upload a sound file first, then enable playback.",
+ "enable_notification_sound": "Enable notification sound",
+ "enable_notification_sound_description": "Plays your chosen sound for incoming messages on desktop (Windows, macOS, and Linux).",
+ "notification_sound_volume": "Notification volume",
+ "notification_sound_default": "Default notification sound",
+ "notification_sound_primary_default": "Primary uploaded sound",
+ "notification_sounds": "Uploaded sounds",
+ "notification_sound_upload": "Upload sound",
+ "notification_sound_uploading": "Uploading...",
+ "notification_sound_none_uploaded": "No notification sound uploaded yet. Upload an audio file to get started.",
+ "notification_sound_primary": "Primary",
+ "notification_sound_preview": "Preview sound",
+ "notification_sound_set_primary": "Set primary",
+ "notification_sound_remove": "Remove sound",
+ "notification_sound_uploaded": "Notification sound uploaded",
+ "notification_sound_upload_failed": "Failed to upload notification sound",
+ "notification_sound_deleted": "Notification sound deleted",
+ "notification_sound_delete_failed": "Failed to delete notification sound",
+ "notification_sound_primary_set": "Primary notification sound updated",
+ "notification_sound_primary_set_failed": "Failed to set primary notification sound",
+ "notification_sound_preview_failed": "Could not play notification sound. Try again after interacting with the app.",
"last_announced": "Last announced: {time}",
"last_announced_never": "Last announced: Never",
"display_name_placeholder": "Display Name",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index b672cbce..42a5dc4e 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -91,6 +91,29 @@
"notifications_no_new": "No hay nuevas notificaciones",
"notifications_empty_history": "No hay historial de notificación",
"notifications_history_title": "Historial de notificación reciente",
+ "notifications": "Notificaciones",
+ "notification_sound_settings": "Sonido de notificación de mensajes",
+ "notification_sound_settings_description": "Reproduce un sonido personalizado cuando recibes un mensaje nuevo mientras MeshChat está abierto. Sube primero un archivo de audio y luego activa la reproducción.",
+ "enable_notification_sound": "Activar sonido de notificación",
+ "enable_notification_sound_description": "Reproduce el sonido elegido para mensajes entrantes en el escritorio (Windows, macOS y Linux).",
+ "notification_sound_volume": "Volumen de notificación",
+ "notification_sound_default": "Sonido de notificación predeterminado",
+ "notification_sound_primary_default": "Sonido principal subido",
+ "notification_sounds": "Sonidos subidos",
+ "notification_sound_upload": "Subir sonido",
+ "notification_sound_uploading": "Subiendo...",
+ "notification_sound_none_uploaded": "Aún no hay sonido de notificación subido. Sube un archivo de audio para empezar.",
+ "notification_sound_primary": "Principal",
+ "notification_sound_preview": "Vista previa del sonido",
+ "notification_sound_set_primary": "Establecer como principal",
+ "notification_sound_remove": "Eliminar sonido",
+ "notification_sound_uploaded": "Sonido de notificación subido",
+ "notification_sound_upload_failed": "Error al subir el sonido de notificación",
+ "notification_sound_deleted": "Sonido de notificación eliminado",
+ "notification_sound_delete_failed": "Error al eliminar el sonido de notificación",
+ "notification_sound_primary_set": "Sonido de notificación principal actualizado",
+ "notification_sound_primary_set_failed": "Error al establecer el sonido principal",
+ "notification_sound_preview_failed": "No se pudo reproducir el sonido de notificación. Inténtalo de nuevo después de interactuar con la aplicación.",
"last_announced": "Última anunciada: {time}",
"last_announced_never": "Última anunciada: Nunca",
"display_name_placeholder": "Nombre",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 4424d62a..331c0c9b 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -92,6 +92,29 @@
"notifications_no_new": "Ei uusia ilmoituksia",
"notifications_empty_history": "Ei ilmoitushistoriaa",
"notifications_history_title": "Viimeaikainen ilmoitushistoria",
+ "notifications": "Ilmoitukset",
+ "notification_sound_settings": "Viestien ilmoitusääni",
+ "notification_sound_settings_description": "Toistaa mukautetun äänen, kun saat uuden viestin MeshChatin ollessa auki. Lataa ensin äänitiedosto ja ota toisto käyttöön.",
+ "enable_notification_sound": "Ota ilmoitusääni käyttöön",
+ "enable_notification_sound_description": "Toistaa valitsemasi äänen saapuville viesteille työpöydällä (Windows, macOS ja Linux).",
+ "notification_sound_volume": "Ilmoituksen äänenvoimakkuus",
+ "notification_sound_default": "Oletusilmoitusääni",
+ "notification_sound_primary_default": "Ensisijainen ladattu ääni",
+ "notification_sounds": "Ladatut äänet",
+ "notification_sound_upload": "Lataa ääni",
+ "notification_sound_uploading": "Ladataan...",
+ "notification_sound_none_uploaded": "Ilmoitusääntä ei ole vielä ladattu. Lataa äänitiedosto aloittaaksesi.",
+ "notification_sound_primary": "Ensisijainen",
+ "notification_sound_preview": "Esikuuntele ääni",
+ "notification_sound_set_primary": "Aseta ensisijaiseksi",
+ "notification_sound_remove": "Poista ääni",
+ "notification_sound_uploaded": "Ilmoitusääni ladattu",
+ "notification_sound_upload_failed": "Ilmoitusäänen lataus epäonnistui",
+ "notification_sound_deleted": "Ilmoitusääni poistettu",
+ "notification_sound_delete_failed": "Ilmoitusäänen poisto epäonnistui",
+ "notification_sound_primary_set": "Ensisijainen ilmoitusääni päivitetty",
+ "notification_sound_primary_set_failed": "Ensisijaisen ilmoitusäänen asetus epäonnistui",
+ "notification_sound_preview_failed": "Ilmoitusääntä ei voitu toistaa. Yritä uudelleen oltuasi vuorovaikutuksessa sovelluksen kanssa.",
"last_announced": "Viimeksi kuulutettu: {time}",
"last_announced_never": "Viimeksi kuulutettu: ei koskaan",
"display_name_placeholder": "Näyttönimi",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index da1b6a5e..219d468b 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -91,6 +91,29 @@
"notifications_no_new": "Aucune nouvelle notification",
"notifications_empty_history": "Aucun historique de notification",
"notifications_history_title": "Historique de la notification récente",
+ "notifications": "Notifications",
+ "notification_sound_settings": "Son de notification de message",
+ "notification_sound_settings_description": "Joue un son personnalisé lorsque vous recevez un nouveau message pendant que MeshChat est ouvert. Téléchargez d'abord un fichier audio, puis activez la lecture.",
+ "enable_notification_sound": "Activer le son de notification",
+ "enable_notification_sound_description": "Joue le son choisi pour les messages entrants sur ordinateur (Windows, macOS et Linux).",
+ "notification_sound_volume": "Volume des notifications",
+ "notification_sound_default": "Son de notification par défaut",
+ "notification_sound_primary_default": "Son principal téléchargé",
+ "notification_sounds": "Sons téléchargés",
+ "notification_sound_upload": "Télécharger un son",
+ "notification_sound_uploading": "Téléchargement...",
+ "notification_sound_none_uploaded": "Aucun son de notification téléchargé. Téléchargez un fichier audio pour commencer.",
+ "notification_sound_primary": "Principal",
+ "notification_sound_preview": "Écouter le son",
+ "notification_sound_set_primary": "Définir comme principal",
+ "notification_sound_remove": "Supprimer le son",
+ "notification_sound_uploaded": "Son de notification téléchargé",
+ "notification_sound_upload_failed": "Échec du téléchargement du son de notification",
+ "notification_sound_deleted": "Son de notification supprimé",
+ "notification_sound_delete_failed": "Échec de la suppression du son de notification",
+ "notification_sound_primary_set": "Son de notification principal mis à jour",
+ "notification_sound_primary_set_failed": "Échec de la définition du son principal",
+ "notification_sound_preview_failed": "Impossible de lire le son de notification. Réessayez après avoir interagi avec l'application.",
"last_announced": "Dernière annonce : {time}",
"last_announced_never": "Dernière annonce: Jamais",
"display_name_placeholder": "Afficher le nom",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 29b55c70..e9bd46b7 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -91,6 +91,29 @@
"notifications_no_new": "Nessuna nuova notifica",
"notifications_empty_history": "Nessuna cronologia notifiche",
"notifications_history_title": "Cronologia notifiche recenti",
+ "notifications": "Notifiche",
+ "notification_sound_settings": "Suono di notifica messaggi",
+ "notification_sound_settings_description": "Riproduce un suono personalizzato quando ricevi un nuovo messaggio mentre MeshChat è aperto. Carica prima un file audio, poi abilita la riproduzione.",
+ "enable_notification_sound": "Abilita suono di notifica",
+ "enable_notification_sound_description": "Riproduce il suono scelto per i messaggi in arrivo su desktop (Windows, macOS e Linux).",
+ "notification_sound_volume": "Volume notifiche",
+ "notification_sound_default": "Suono di notifica predefinito",
+ "notification_sound_primary_default": "Suono principale caricato",
+ "notification_sounds": "Suoni caricati",
+ "notification_sound_upload": "Carica suono",
+ "notification_sound_uploading": "Caricamento...",
+ "notification_sound_none_uploaded": "Nessun suono di notifica caricato. Carica un file audio per iniziare.",
+ "notification_sound_primary": "Principale",
+ "notification_sound_preview": "Anteprima suono",
+ "notification_sound_set_primary": "Imposta come principale",
+ "notification_sound_remove": "Rimuovi suono",
+ "notification_sound_uploaded": "Suono di notifica caricato",
+ "notification_sound_upload_failed": "Caricamento del suono di notifica non riuscito",
+ "notification_sound_deleted": "Suono di notifica eliminato",
+ "notification_sound_delete_failed": "Eliminazione del suono di notifica non riuscita",
+ "notification_sound_primary_set": "Suono di notifica principale aggiornato",
+ "notification_sound_primary_set_failed": "Impostazione del suono principale non riuscita",
+ "notification_sound_preview_failed": "Impossibile riprodurre il suono di notifica. Riprova dopo aver interagito con l'app.",
"last_announced": "Ultimo annuncio: {time}",
"last_announced_never": "Ultimo annuncio: Mai",
"display_name_placeholder": "Nome Visualizzato",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index a8a3c0ca..57c37600 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -91,6 +91,29 @@
"notifications_no_new": "Geen nieuwe meldingen",
"notifications_empty_history": "Geen meldingsgeschiedenis",
"notifications_history_title": "Recente meldingsgeschiedenis",
+ "notifications": "Meldingen",
+ "notification_sound_settings": "Meldingsgeluid voor berichten",
+ "notification_sound_settings_description": "Speelt een aangepast geluid af wanneer je een nieuw bericht ontvangt terwijl MeshChat open is. Upload eerst een audiobestand en schakel daarna afspelen in.",
+ "enable_notification_sound": "Meldingsgeluid inschakelen",
+ "enable_notification_sound_description": "Speelt het gekozen geluid af voor inkomende berichten op desktop (Windows, macOS en Linux).",
+ "notification_sound_volume": "Meldingsvolume",
+ "notification_sound_default": "Standaard meldingsgeluid",
+ "notification_sound_primary_default": "Primair geüpload geluid",
+ "notification_sounds": "Geüploade geluiden",
+ "notification_sound_upload": "Geluid uploaden",
+ "notification_sound_uploading": "Uploaden...",
+ "notification_sound_none_uploaded": "Nog geen meldingsgeluid geüpload. Upload een audiobestand om te beginnen.",
+ "notification_sound_primary": "Primair",
+ "notification_sound_preview": "Geluid beluisteren",
+ "notification_sound_set_primary": "Instellen als primair",
+ "notification_sound_remove": "Geluid verwijderen",
+ "notification_sound_uploaded": "Meldingsgeluid geüpload",
+ "notification_sound_upload_failed": "Uploaden van meldingsgeluid mislukt",
+ "notification_sound_deleted": "Meldingsgeluid verwijderd",
+ "notification_sound_delete_failed": "Verwijderen van meldingsgeluid mislukt",
+ "notification_sound_primary_set": "Primair meldingsgeluid bijgewerkt",
+ "notification_sound_primary_set_failed": "Instellen van primair meldingsgeluid mislukt",
+ "notification_sound_preview_failed": "Meldingsgeluid kon niet worden afgespeeld. Probeer het opnieuw nadat je met de app hebt geïnteracteerd.",
"last_announced": "Laatst aangekondigd: {time}",
"last_announced_never": "Laatst aangekondigd: nooit",
"display_name_placeholder": "Naam tonen",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index c7d7a3cb..6bf1a0bb 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -356,6 +356,29 @@
"notifications_no_new": "Нет новых уведомлений",
"notifications_empty_history": "Нет истории уведомлений",
"notifications_history_title": "Недавняя история уведомлений",
+ "notifications": "Уведомления",
+ "notification_sound_settings": "Звук уведомления о сообщении",
+ "notification_sound_settings_description": "Воспроизводит пользовательский звук при получении нового сообщения, пока MeshChat открыт. Сначала загрузите аудиофайл, затем включите воспроизведение.",
+ "enable_notification_sound": "Включить звук уведомления",
+ "enable_notification_sound_description": "Воспроизводит выбранный звук для входящих сообщений на настольных системах (Windows, macOS и Linux).",
+ "notification_sound_volume": "Громкость уведомлений",
+ "notification_sound_default": "Звук уведомления по умолчанию",
+ "notification_sound_primary_default": "Основной загруженный звук",
+ "notification_sounds": "Загруженные звуки",
+ "notification_sound_upload": "Загрузить звук",
+ "notification_sound_uploading": "Загрузка...",
+ "notification_sound_none_uploaded": "Звук уведомления ещё не загружен. Загрузите аудиофайл, чтобы начать.",
+ "notification_sound_primary": "Основной",
+ "notification_sound_preview": "Прослушать звук",
+ "notification_sound_set_primary": "Сделать основным",
+ "notification_sound_remove": "Удалить звук",
+ "notification_sound_uploaded": "Звук уведомления загружен",
+ "notification_sound_upload_failed": "Не удалось загрузить звук уведомления",
+ "notification_sound_deleted": "Звук уведомления удалён",
+ "notification_sound_delete_failed": "Не удалось удалить звук уведомления",
+ "notification_sound_primary_set": "Основной звук уведомления обновлён",
+ "notification_sound_primary_set_failed": "Не удалось установить основной звук",
+ "notification_sound_preview_failed": "Не удалось воспроизвести звук уведомления. Попробуйте снова после взаимодействия с приложением.",
"search_settings": "Поиск настроек...",
"show_qr": "Показать QR-код",
"csp_settings": "Политика безопасности контента (CSP)",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index a232237c..5e2988cd 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -91,6 +91,29 @@
"notifications_no_new": "没有新通知",
"notifications_empty_history": "无通知历史",
"notifications_history_title": "最近通知历史",
+ "notifications": "通知",
+ "notification_sound_settings": "消息通知声音",
+ "notification_sound_settings_description": "在 MeshChat 打开时收到新消息播放自定义声音。请先上传音频文件,然后启用播放。",
+ "enable_notification_sound": "启用通知声音",
+ "enable_notification_sound_description": "在桌面端(Windows、macOS 和 Linux)为收到的消息播放所选声音。",
+ "notification_sound_volume": "通知音量",
+ "notification_sound_default": "默认通知声音",
+ "notification_sound_primary_default": "主要上传声音",
+ "notification_sounds": "已上传声音",
+ "notification_sound_upload": "上传声音",
+ "notification_sound_uploading": "上传中...",
+ "notification_sound_none_uploaded": "尚未上传通知声音。请上传音频文件以开始使用。",
+ "notification_sound_primary": "主要",
+ "notification_sound_preview": "预览声音",
+ "notification_sound_set_primary": "设为主要",
+ "notification_sound_remove": "删除声音",
+ "notification_sound_uploaded": "通知声音已上传",
+ "notification_sound_upload_failed": "通知声音上传失败",
+ "notification_sound_deleted": "通知声音已删除",
+ "notification_sound_delete_failed": "通知声音删除失败",
+ "notification_sound_primary_set": "主要通知声音已更新",
+ "notification_sound_primary_set_failed": "设置主要通知声音失败",
+ "notification_sound_preview_failed": "无法播放通知声音。请与应用程序交互后重试。",
"last_announced": "上次广播:{time}",
"last_announced_never": "上次广播:从未",
"display_name_placeholder": "显示名称",
diff --git a/tests/backend/test_config_manager.py b/tests/backend/test_config_manager.py
index 07156ec4..a5e39805 100644
--- a/tests/backend/test_config_manager.py
+++ b/tests/backend/test_config_manager.py
@@ -125,6 +125,10 @@ def test_all_telephony_settings_persist(db):
config.ringtone_volume.set(75)
config.ringtone_preferred_id.set(3)
+ config.notification_sound_enabled.set(True)
+ config.notification_sound_preferred_id.set(2)
+ config.notification_sound_volume.set(55)
+
# Desktop / misc
config.desktop_open_calls_in_separate_window.set(True)
@@ -159,6 +163,11 @@ def test_all_telephony_settings_persist(db):
assert config2.ringtone_volume.get() == 75
assert config2.ringtone_preferred_id.get() == 3
+ # Notification sound
+ assert config2.notification_sound_enabled.get() is True
+ assert config2.notification_sound_preferred_id.get() == 2
+ assert config2.notification_sound_volume.get() == 55
+
# Desktop
assert config2.desktop_open_calls_in_separate_window.get() is True
diff --git a/tests/backend/test_notification_sounds.py b/tests/backend/test_notification_sounds.py
new file mode 100644
index 00000000..4c7f4358
--- /dev/null
+++ b/tests/backend/test_notification_sounds.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: 0BSD
+
+import os
+import tempfile
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshchatx.src.backend.config_manager import ConfigManager
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.ringtone_manager import RingtoneManager
+
+
+@pytest.fixture
+def db():
+ fd, path = tempfile.mkstemp()
+ os.close(fd)
+ database = Database(path)
+ database.initialize()
+ yield database
+ database.close()
+ if os.path.exists(path):
+ os.remove(path)
+
+
+def test_notification_sound_config_defaults(db):
+ config = ConfigManager(db)
+ assert config.notification_sound_enabled.get() is False
+ assert config.notification_sound_preferred_id.get() == 0
+ assert config.notification_sound_volume.get() == 100
+
+
+def test_notification_sound_config_persists(db):
+ config = ConfigManager(db)
+ config.notification_sound_enabled.set(True)
+ config.notification_sound_preferred_id.set(4)
+ config.notification_sound_volume.set(60)
+
+ config2 = ConfigManager(db)
+ assert config2.notification_sound_enabled.get() is True
+ assert config2.notification_sound_preferred_id.get() == 4
+ assert config2.notification_sound_volume.get() == 60
+
+
+def test_notification_sounds_dao_crud(db):
+ sound_id = db.notification_sounds.add(
+ filename="alert.mp3",
+ storage_filename="notification_abcd.opus",
+ display_name="Alert",
+ )
+ assert sound_id > 0
+
+ row = db.notification_sounds.get_by_id(sound_id)
+ assert row["filename"] == "alert.mp3"
+ assert row["display_name"] == "Alert"
+ assert row["is_primary"] == 1
+
+ all_sounds = db.notification_sounds.get_all()
+ assert len(all_sounds) == 1
+
+ primary = db.notification_sounds.get_primary()
+ assert primary["id"] == sound_id
+
+ db.notification_sounds.update(sound_id, display_name="Updated Alert")
+ updated = db.notification_sounds.get_by_id(sound_id)
+ assert updated["display_name"] == "Updated Alert"
+
+ db.notification_sounds.delete(sound_id)
+ assert db.notification_sounds.get_by_id(sound_id) is None
+
+
+def test_notification_sound_manager_uses_separate_storage_dir(tmp_path):
+ config = MagicMock()
+ manager = RingtoneManager(
+ config,
+ str(tmp_path),
+ asset_subdir="notification_sounds",
+ filename_prefix="notification",
+ )
+ assert manager.storage_dir == os.path.join(str(tmp_path), "notification_sounds")
+ assert os.path.isdir(manager.storage_dir)
diff --git a/tests/backend/test_telemetry_integration.py b/tests/backend/test_telemetry_integration.py
index 8f7f8ed1..2be5d211 100644
--- a/tests/backend/test_telemetry_integration.py
+++ b/tests/backend/test_telemetry_integration.py
@@ -164,6 +164,5 @@ async def test_telemetry_request_no_location_does_not_call_handler(mock_app):
mock_app.handle_telemetry_request.assert_not_called()
-
# We can't easily test the web endpoint here without more setup,
# but we can test the logic it calls if it was refactored into a method.
diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py
index 501e0217..34b0f9f1 100644
--- a/tests/backend/test_translator_handler_extended.py
+++ b/tests/backend/test_translator_handler_extended.py
@@ -108,7 +108,6 @@ def test_translate_argos_cli(mock_run):
)
assert result["translated_text"] == "Hola"
-
# _detect_language is private
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 01d3af81..03c02f4f 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -122,7 +122,7 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.emitted("reload-conversations")).toBeFalsy();
});
- it("markConversationAsRead marks read and reloads once when conversation is unread", async () => {
+ it("markConversationAsRead marks read without reloading conversations when conversation is unread", async () => {
const wrapper = mountConversationViewer();
await flushPromises();
axiosMock.post.mockClear();
@@ -135,7 +135,7 @@ describe("ConversationViewer.vue", () => {
expect(conversation.is_unread).toBe(false);
const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
expect(markCalls).toHaveLength(1);
- expect(wrapper.emitted("reload-conversations")).toHaveLength(1);
+ expect(wrapper.emitted("reload-conversations")).toBeFalsy();
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
});
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index bb91eaf2..4a51f4d4 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -1,4 +1,4 @@
-import { mount } from "@vue/test-utils";
+import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import MessagesPage from "@/components/messages/MessagesPage.vue";
import GlobalEmitter from "@/js/GlobalEmitter";
@@ -69,6 +69,16 @@ describe("MessagesPage.vue", () => {
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object));
});
+ it("does not fetch lxmf delivery announces until the announces tab is opened", async () => {
+ mountMessagesPage();
+ await flushPromises();
+
+ const announceCalls = axiosMock.get.mock.calls.filter(
+ (call) => call[0] === "/api/v1/announces" && call[1]?.params?.aspect === "lxmf.delivery"
+ );
+ expect(announceCalls).toHaveLength(0);
+ });
+
it("debounces conversation search and sends search param to conversations API", async () => {
vi.useFakeTimers();
axiosMock.isCancel = vi.fn(() => false);
diff --git a/tests/frontend/MountLoadPerformance.test.js b/tests/frontend/MountLoadPerformance.test.js
new file mode 100644
index 00000000..f0ece9df
--- /dev/null
+++ b/tests/frontend/MountLoadPerformance.test.js
@@ -0,0 +1,234 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MessagesPage from "@/components/messages/MessagesPage.vue";
+import NomadNetworkBrowser from "@/components/nomadnetwork/NomadNetworkBrowser.vue";
+import { conversationListSignature, syncConversationListInPlace } from "@/js/lxmfConversationListSync";
+
+const MAX_MESSAGES_MOUNT_MS = 2500;
+const MAX_NOMAD_BROWSER_RESTORE_MS = 1500;
+
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: { on: vi.fn(), off: vi.fn() },
+}));
+
+vi.mock("@/components/nomadnetwork/NomadNetworkPage.vue", () => ({
+ default: {
+ name: "NomadNetworkPage",
+ template: '<div class="nnp-stub" :data-hash="destinationHash" :data-active="isActive ? \'1\' : \'0\'"></div>',
+ props: {
+ destinationHash: { type: String, default: "" },
+ initialPath: { type: String, default: null },
+ embedded: { type: Boolean, default: false },
+ tabsEnabled: { type: Boolean, default: false },
+ isActive: { type: Boolean, default: true },
+ },
+ emits: ["navigate", "open-node", "close-tab"],
+ },
+}));
+
+const MaterialDesignIconStub = {
+ name: "MaterialDesignIcon",
+ template: '<div class="mdi-stub"></div>',
+ props: ["iconName"],
+};
+
+describe("Mount load performance regressions", () => {
+ describe("MessagesPage mount", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ localStorage.clear();
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ isCancel: vi.fn(() => false),
+ };
+ window.api = axiosMock;
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ }
+ if (url === "/api/v1/lxmf/conversations") {
+ return Promise.resolve({ data: { conversations: [] } });
+ }
+ if (url === "/api/v1/lxmf/conversation-pins") {
+ return Promise.resolve({ data: { peer_hashes: [] } });
+ }
+ if (url === "/api/v1/lxmf/folders") {
+ return Promise.resolve({ data: { folders: [] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ const mountMessagesPage = () =>
+ mount(MessagesPage, {
+ props: { destinationHash: "" },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {} },
+ $router: { replace: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: MaterialDesignIconStub,
+ LoadingSpinner: true,
+ MessagesSidebar: {
+ template: '<div class="sidebar-stub"></div>',
+ props: ["conversations", "selectedDestinationHash"],
+ },
+ ConversationViewer: {
+ template: '<div class="viewer-stub"></div>',
+ props: ["selectedPeer", "myLxmfAddressHash"],
+ },
+ Modal: true,
+ },
+ },
+ });
+
+ it("does not fetch lxmf delivery announces on initial mount", async () => {
+ const wrapper = mountMessagesPage();
+ await flushPromises();
+
+ const announceCalls = axiosMock.get.mock.calls.filter(
+ (call) => call[0] === "/api/v1/announces" && call[1]?.params?.aspect === "lxmf.delivery"
+ );
+ expect(announceCalls).toHaveLength(0);
+ expect(wrapper.vm.announcesLoaded).toBe(false);
+ });
+
+ it("mounts within the messages page budget", async () => {
+ const start = performance.now();
+ mountMessagesPage();
+ await flushPromises();
+ const elapsed = performance.now() - start;
+ expect(elapsed).toBeLessThan(MAX_MESSAGES_MOUNT_MS);
+ });
+
+ it("skips redundant conversation list replacement when polling returns the same signature", async () => {
+ const conversations = Array.from({ length: 250 }, (_, index) => ({
+ destination_hash: index.toString(16).padStart(32, "0"),
+ display_name: `Peer ${index}`,
+ updated_at: "2026-01-01T00:00:00Z",
+ is_unread: index % 7 === 0,
+ failed_messages_count: 0,
+ latest_message_created_at: index,
+ latest_message_preview: `Preview ${index}`,
+ }));
+ const signature = conversationListSignature(conversations);
+ const existing = conversations.map((conversation) => ({ ...conversation }));
+ const refsBefore = existing.map((conversation) => conversation);
+
+ syncConversationListInPlace(
+ existing,
+ conversations.map((conversation) => ({ ...conversation }))
+ );
+
+ expect(conversationListSignature(existing)).toBe(signature);
+ expect(existing.every((conversation, index) => conversation === refsBefore[index])).toBe(true);
+ });
+ });
+
+ describe("NomadNetworkBrowser restore", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ const mountBrowser = () =>
+ mount(NomadNetworkBrowser, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { name: "nomadnetwork", params: {}, query: {} },
+ $router: { replace: vi.fn(() => Promise.resolve()) },
+ },
+ stubs: {
+ MaterialDesignIcon: MaterialDesignIconStub,
+ NomadBrowserContextMenu: true,
+ },
+ },
+ });
+
+ it("only mounts the active tab page when multiple tabs are restored", async () => {
+ localStorage.setItem(
+ "meshchatx.nomadnet.tabs",
+ JSON.stringify({
+ tabs: [
+ { destinationHash: "a".repeat(32), path: null, title: "Alpha" },
+ { destinationHash: "b".repeat(32), path: null, title: "Bravo" },
+ { destinationHash: "c".repeat(32), path: null, title: "Charlie" },
+ ],
+ activeIndex: 1,
+ })
+ );
+
+ const wrapper = mountBrowser();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.tabs).toHaveLength(3);
+ expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1);
+ expect(wrapper.vm.isTabMounted(wrapper.vm.activeTabId)).toBe(true);
+ });
+
+ it("mounts inactive tabs lazily when they are selected", async () => {
+ localStorage.setItem(
+ "meshchatx.nomadnet.tabs",
+ JSON.stringify({
+ tabs: [
+ { destinationHash: "a".repeat(32), path: null, title: "Alpha" },
+ { destinationHash: "b".repeat(32), path: null, title: "Bravo" },
+ ],
+ activeIndex: 0,
+ })
+ );
+
+ const wrapper = mountBrowser();
+ await wrapper.vm.$nextTick();
+ const secondTabId = wrapper.vm.tabs[1].id;
+
+ wrapper.vm.selectTab(secondTabId);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(2);
+ expect(wrapper.vm.isTabMounted(secondTabId)).toBe(true);
+ });
+
+ it("restores multiple tabs within the browser mount budget", async () => {
+ localStorage.setItem(
+ "meshchatx.nomadnet.tabs",
+ JSON.stringify({
+ tabs: Array.from({ length: 8 }, (_, index) => ({
+ destinationHash: `${index}`.padStart(32, "a"),
+ path: null,
+ title: `Tab ${index}`,
+ })),
+ activeIndex: 3,
+ })
+ );
+
+ const start = performance.now();
+ const wrapper = mountBrowser();
+ await wrapper.vm.$nextTick();
+ const elapsed = performance.now() - start;
+
+ expect(wrapper.vm.tabs).toHaveLength(8);
+ expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1);
+ expect(elapsed).toBeLessThan(MAX_NOMAD_BROWSER_RESTORE_MS);
+ });
+ });
+});
diff --git a/tests/frontend/NomadNetworkBrowser.test.js b/tests/frontend/NomadNetworkBrowser.test.js
index f43290aa..bb868aba 100644
--- a/tests/frontend/NomadNetworkBrowser.test.js
+++ b/tests/frontend/NomadNetworkBrowser.test.js
@@ -223,7 +223,27 @@ describe("NomadNetworkBrowser.vue", () => {
expect(wrapper.vm.activeTab.destinationHash).toBe("b".repeat(32));
});
- it("renders one embedded NomadNetworkPage per tab", async () => {
+ it("only mounts the active tab page when multiple tabs are restored", async () => {
+ localStorage.setItem(
+ "meshchatx.nomadnet.tabs",
+ JSON.stringify({
+ tabs: [
+ { destinationHash: "a".repeat(32), path: null, title: "Alpha" },
+ { destinationHash: "b".repeat(32), path: null, title: "Bravo" },
+ ],
+ activeIndex: 1,
+ })
+ );
+
+ const wrapper = mountBrowser({}, { params: { destinationHash: "b".repeat(32) }, query: {} });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.tabs).toHaveLength(2);
+ expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1);
+ expect(wrapper.vm.activeTab.destinationHash).toBe("b".repeat(32));
+ });
+
+ it("renders one embedded NomadNetworkPage per activated tab", async () => {
const wrapper = mountBrowser();
wrapper.vm.addTab("f".repeat(32));
await wrapper.vm.$nextTick();
diff --git a/tests/frontend/NotificationSoundUtils.test.js b/tests/frontend/NotificationSoundUtils.test.js
new file mode 100644
index 00000000..39a4da9e
--- /dev/null
+++ b/tests/frontend/NotificationSoundUtils.test.js
@@ -0,0 +1,121 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import NotificationSoundUtils from "../../meshchatx/src/frontend/js/NotificationSoundUtils";
+
+describe("NotificationSoundUtils", () => {
+ let audioInstances;
+ let originalAudio;
+
+ beforeEach(() => {
+ audioInstances = [];
+ originalAudio = globalThis.Audio;
+ globalThis.Audio = vi.fn(function (src) {
+ const player = {
+ src,
+ loop: false,
+ volume: 1,
+ currentTime: 0,
+ onended: null,
+ play: vi.fn().mockResolvedValue(undefined),
+ pause: vi.fn(),
+ };
+ audioInstances.push(player);
+ return player;
+ });
+ globalThis.window = globalThis.window || {};
+ globalThis.window.api = {
+ get: vi.fn(),
+ };
+ NotificationSoundUtils.stop();
+ NotificationSoundUtils.autoplayBlocked = false;
+ });
+
+ afterEach(() => {
+ globalThis.Audio = originalAudio;
+ vi.restoreAllMocks();
+ });
+
+ it("shouldPlay returns false when disabled", () => {
+ expect(NotificationSoundUtils.shouldPlay({ notification_sound_enabled: false })).toBe(false);
+ expect(NotificationSoundUtils.shouldPlay({ notification_sound_enabled: true })).toBe(true);
+ expect(NotificationSoundUtils.shouldPlay(null)).toBe(false);
+ });
+
+ it("play skips when disabled", async () => {
+ const result = await NotificationSoundUtils.play({ notification_sound_enabled: false });
+ expect(result).toBe(false);
+ expect(globalThis.window.api.get).not.toHaveBeenCalled();
+ });
+
+ it("play skips when status has no sound", async () => {
+ globalThis.window.api.get.mockResolvedValue({
+ data: { enabled: true, has_sound: false, id: null },
+ });
+ const result = await NotificationSoundUtils.play({ notification_sound_enabled: true });
+ expect(result).toBe(false);
+ expect(globalThis.Audio).not.toHaveBeenCalled();
+ });
+
+ it("play creates audio when configured", async () => {
+ globalThis.window.api.get.mockResolvedValue({
+ data: { enabled: true, has_sound: true, id: 7, volume: 0.5 },
+ });
+ const result = await NotificationSoundUtils.play({ notification_sound_enabled: true });
+ expect(result).toBe(true);
+ expect(globalThis.window.api.get).toHaveBeenCalledWith("/api/v1/notification-sounds/status");
+ expect(globalThis.Audio).toHaveBeenCalledWith("/api/v1/notification-sounds/7/audio");
+ expect(audioInstances[0].volume).toBe(0.5);
+ expect(audioInstances[0].loop).toBe(false);
+ expect(audioInstances[0].play).toHaveBeenCalled();
+ });
+
+ it("play sets autoplayBlocked on NotAllowedError", async () => {
+ globalThis.window.api.get.mockResolvedValue({
+ data: { enabled: true, has_sound: true, id: 2, volume: 1 },
+ });
+ globalThis.Audio = vi.fn(function () {
+ const player = {
+ loop: false,
+ volume: 1,
+ onended: null,
+ play: vi.fn().mockRejectedValue(Object.assign(new Error("blocked"), { name: "NotAllowedError" })),
+ pause: vi.fn(),
+ currentTime: 0,
+ };
+ audioInstances.push(player);
+ return player;
+ });
+
+ const result = await NotificationSoundUtils.play({ notification_sound_enabled: true });
+ expect(result).toBe(false);
+ expect(NotificationSoundUtils.autoplayBlocked).toBe(true);
+ });
+
+ it("play returns false while autoplayBlocked", async () => {
+ NotificationSoundUtils.autoplayBlocked = true;
+ const result = await NotificationSoundUtils.play({ notification_sound_enabled: true });
+ expect(result).toBe(false);
+ expect(globalThis.window.api.get).not.toHaveBeenCalled();
+ });
+
+ it("unlockAutoplay clears blocked flag", () => {
+ NotificationSoundUtils.autoplayBlocked = true;
+ NotificationSoundUtils.unlockAutoplay();
+ expect(NotificationSoundUtils.autoplayBlocked).toBe(false);
+ });
+
+ it("stop pauses active player", async () => {
+ globalThis.window.api.get.mockResolvedValue({
+ data: { enabled: true, has_sound: true, id: 1, volume: 1 },
+ });
+ await NotificationSoundUtils.play({ notification_sound_enabled: true });
+ NotificationSoundUtils.stop();
+ expect(audioInstances[0].pause).toHaveBeenCalled();
+ });
+
+ it("preview plays selected sound", async () => {
+ const result = await NotificationSoundUtils.preview(9, 80);
+ expect(result).toBe(true);
+ expect(globalThis.Audio).toHaveBeenCalledWith("/api/v1/notification-sounds/9/audio");
+ expect(audioInstances[0].volume).toBeCloseTo(0.8);
+ });
+});
diff --git a/tests/frontend/NotificationUtils.test.js b/tests/frontend/NotificationUtils.test.js
index 1da598fb..bdfc742b 100644
--- a/tests/frontend/NotificationUtils.test.js
+++ b/tests/frontend/NotificationUtils.test.js
@@ -36,7 +36,12 @@ describe("NotificationUtils", () => {
it("showNewMessageNotification delegates to electron", () => {
NotificationUtils.showNewMessageNotification("Alice", "hello");
- expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello");
+ expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", false);
+ });
+
+ it("showNewMessageNotification passes silent flag to electron", () => {
+ NotificationUtils.showNewMessageNotification("Alice", "hello", true);
+ expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", true);
});
it("showIncomingCallNotification delegates to electron", () => {
diff --git a/tests/frontend/lxmfConversationListSync.test.js b/tests/frontend/lxmfConversationListSync.test.js
new file mode 100644
index 00000000..c93b3ef0
--- /dev/null
+++ b/tests/frontend/lxmfConversationListSync.test.js
@@ -0,0 +1,62 @@
+import { describe, it, expect } from "vitest";
+import {
+ conversationListSignature,
+ countUnreadConversations,
+ syncConversationListInPlace,
+} from "../../meshchatx/src/frontend/js/lxmfConversationListSync.js";
+
+describe("lxmfConversationListSync", () => {
+ it("builds a stable signature for conversation rows", () => {
+ const conversations = [
+ {
+ destination_hash: "a".repeat(32),
+ updated_at: "2026-01-01T00:00:00Z",
+ is_unread: true,
+ failed_messages_count: 0,
+ latest_message_created_at: 1,
+ latest_message_preview: "hello",
+ },
+ ];
+ expect(conversationListSignature(conversations)).toBe(conversationListSignature(conversations.slice()));
+ });
+
+ it("counts unread conversations", () => {
+ expect(countUnreadConversations([{ is_unread: true }, { is_unread: false }, { is_unread: true }])).toBe(2);
+ });
+
+ it("syncs updated rows in place while preserving object identity", () => {
+ const existing = [
+ { destination_hash: "a".repeat(32), display_name: "Alpha", is_unread: true },
+ { destination_hash: "b".repeat(32), display_name: "Bravo", is_unread: false },
+ ];
+ const alphaRef = existing[0];
+ const incoming = [
+ { destination_hash: "a".repeat(32), display_name: "Alpha", is_unread: false },
+ { destination_hash: "b".repeat(32), display_name: "Bravo", is_unread: false },
+ ];
+
+ const changed = syncConversationListInPlace(existing, incoming);
+
+ expect(changed).toBe(false);
+ expect(existing[0]).toBe(alphaRef);
+ expect(existing[0].is_unread).toBe(false);
+ expect(existing).toHaveLength(2);
+ });
+
+ it("reorders and appends rows when the server order changes", () => {
+ const existing = [
+ { destination_hash: "a".repeat(32), display_name: "Alpha" },
+ { destination_hash: "b".repeat(32), display_name: "Bravo" },
+ ];
+ const incoming = [
+ { destination_hash: "c".repeat(32), display_name: "Charlie" },
+ { destination_hash: "a".repeat(32), display_name: "Alpha" },
+ ];
+
+ const changed = syncConversationListInPlace(existing, incoming);
+
+ expect(changed).toBe(true);
+ expect(existing.map((row) => row.destination_hash)).toEqual(["c".repeat(32), "a".repeat(32)]);
+ expect(existing[1].display_name).toBe("Alpha");
+ });
+});
diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js
index 94617cba..55e5d4d5 100644
--- a/tests/frontend/settingsTabs.test.js
+++ b/tests/frontend/settingsTabs.test.js
@@ -39,6 +39,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
"infrastructure",
"csp",
"messages",
+ "notificationSounds",
"propagation",
"shortcuts",
];
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────